Skip to main content

Filter - CS50x 2023

实现一个程序,该程序将滤镜效果应用到 BMP 图像,如下所示。

$ ./filter -r IMAGE.bmp REFLECTED.bmp

其中 IMAGE.bmp 是图像文件的名称,REFLECTED.bmp 是输出图像文件的名称,现在已镜像处理。

背景

位图

表示图像最简单的方法可能是使用像素(即点)网格,每个像素可以是不同的颜色。 对于黑白图像,因此我们需要每个像素 1 位,因为 0 可以表示黑色,1 可以表示白色,如下所示。

一个简单的位图

因此,从某种意义上说,图像就是一个位图(即位的映射)。 对于颜色更丰富的图像,您只需要每个像素更多的位。 支持“24 位颜色”的文件格式(如 BMPJPEGPNG)每个像素使用 24 位。(BMP 实际上支持 1 位、4 位、8 位、16 位、24 位和 32 位颜色。)

24 位 BMP 使用 8 位分别表示像素颜色中的红色、绿色和蓝色分量。 如果您听说过 RGB 颜色,那么这里指的就是:红色、绿色和蓝色。

如果 BMP 中某个像素的 R、G 和 B 值(例如)是十六进制的 0xff0x000x00,则该像素是纯红色,因为 0xff(也称为十进制的 255)表示“大量红色”,而 0x000x00 分别表示“没有绿色”和“没有蓝色”。

位图技术详解

请回忆一下,文件本质上是以某种方式排列的一系列二进制位。 那么,一个 24 位 BMP 文件本质上只是一系列二进制位,其中(几乎)每 24 位都代表某个像素的颜色。 但是,BMP 文件还包含一些“元数据”,例如图像的高度和宽度等信息。 该元数据以两种数据结构的形式存储在文件的开头,通常称为“标头”,不要与 C 的头文件混淆。(值得一提的是,这些标头是随着时间推移而不断演变的。此问题使用 Microsoft BMP 格式的最新版本 4.0,该版本随 Windows 95 首次亮相。)

这些标头中的第一个,称为 BITMAPFILEHEADER,长 14 个字节。(回想一下,1 个字节等于 8 位。)这些标头中的第二个,称为 BITMAPINFOHEADER,长 40 个字节。 紧随这些标头之后的是实际的位图数据:一个字节数组,每三个字节代表一个像素的颜色。 但是,BMP 以相反的顺序存储这些三元组(即,作为 BGR),其中 8 位用于蓝色,然后是 8 位用于绿色,然后是 8 位用于红色。(某些 BMP 还会以倒序存储整个位图数据,即图像的顶行存储在 BMP 文件的末尾。但是,我们已将此问题集的 BMP 存储在此处描述的方式中,每个位图的顶行在前,底行在后。)换句话说,如果我们要将上面的 1 位笑脸转换为 24 位笑脸,用红色代替黑色,则 24 位 BMP 将按如下方式存储此位图,其中 0000ff 表示红色,ffffff 表示白色; 我们用红色高亮显示了所有 0000ff 的示例。

红色笑脸 因为我们将这些位从左到右、从上到下,以8列的形式排列,所以稍微后退几步,你就能看到红色的笑脸图案。

为了更清楚地说明,请记住,一个十六进制位代表4个二进制位。因此,十六进制的ffffff实际上对应于二进制的111111111111111111111111

注意,可以将位图表示为像素的二维数组:图像由行组成,每一行又是由像素组成的数组。 实际上,这就是我们在本问题中表示位图图像的方法。

图像滤镜

图像滤波到底是什么意思? 你可以这样理解图像滤波:获取原始图像的像素,然后以某种方式修改每个像素,从而在结果图像中产生特定的效果。

灰度

一种常见的滤镜是“灰度”滤镜,它将图像转换为黑白图像。 它是如何实现的呢?

回想一下,如果红色、绿色和蓝色的值都设置为 0x00 (十六进制,表示 0),那么该像素就是黑色的。 如果所有值都设置为 0xff (十六进制,表示 255),那么该像素就是白色的。 只要红色、绿色和蓝色的值相等,结果就会呈现出黑白光谱中的不同灰度,数值越高,阴影越浅 (越接近白色),数值越低,阴影越深 (越接近黑色)。

因此,要将像素转换为灰度,我们只需要保证红色、绿色和蓝色的值相同即可。 那么,应该将它们设置为哪个值呢? 比较合理的做法是,如果原始的红色、绿色和蓝色值都比较高,那么新的值也应该比较高;如果原始值都比较低,那么新的值也应该比较低。

实际上,为了确保新图像中每个像素的整体亮度与原始图像大致相同,我们可以计算红色、绿色和蓝色值的平均值,以此来确定新像素的灰度值。

将此方法应用于图像中的每个像素,就能得到灰度图像。

反射

有些滤镜还会移动像素。 例如,图像反射是一种滤镜,其效果就像将原始图像放在镜子前一样。 因此,图像左侧的像素会出现在右侧,反之亦然。

需要注意的是,原始图像中的所有像素仍然会出现在反射图像中,只不过它们的位置发生了改变。

模糊

有很多方法可以实现模糊或柔化图像的效果。 在本问题中,我们将使用“盒状模糊”方法,该方法通过获取每个像素,并对相邻像素的颜色值取平均,从而得到该像素新的颜色值。

每个像素的新值,将是其周围一圈像素 (上下左右各一个像素,形成一个 3x3 的区域) 的颜色值的平均值。 例如,像素 6 的每个颜色值,是通过计算像素 1、2、3、5、6、7、9、10 和 11 的原始颜色值的平均值得到的 (注意,像素 6 本身也包含在内)。 同样,像素 11 的颜色值,是通过计算像素 6、7、8、10、11、12、14、15 和 16 的颜色值的平均值得到的。

对于位于边缘或角落的像素,例如像素 15,我们仍然查找其周围一圈的像素 (上下左右各一个像素):在这种情况下,涉及的像素为 10、11、12、14、15 和 16。

边缘检测

在图像处理的人工智能算法中,检测图像边缘很有用,边缘指的是图像中构成物体边界的线条。实现此效果的一种方法是将 Sobel 算子 (索贝尔算子) 应用于图像。

与图像模糊类似,边缘检测也是通过获取每个像素,并根据其周围的 3x3 像素网格进行修改。但索贝尔算子并非简单地计算周围九个像素的平均值,而是通过加权求和计算每个像素的新值。由于物体边缘可能出现在水平和垂直方向,因此需要计算两个加权和:分别用于检测 x 和 y 方向的边缘。具体来说,您将使用以下两个“卷积核”:

Sobel kernels

如何理解这些卷积核?例如,要计算像素红色通道的 Gx 值,取该像素周围 3x3 区域内九个像素的原始红色值,分别乘以 Gx 卷积核中对应位置的权重,然后求和。

为什么卷积核采用这些特定值?例如,在 Gx 方向上,我们将目标像素右侧的像素乘以正权重,左侧的像素乘以负权重。求和后,如果左右两侧像素颜色相近,结果将接近于 0 (正负抵消)。反之,如果左右两侧像素差异很大,结果值将为很大的正数或负数,表明颜色突变,可能存在物体边界。类似的原理也适用于 y 方向的边缘检测。

使用这些卷积核,可以计算像素红、绿、蓝三个通道的 GxGy 值。但每个通道只能有一个值,因此需要将 GxGy 合并为一个值。索贝尔滤波器算法通过计算 Gx^2 + Gy^2 的平方根,得到最终的边缘强度值。由于通道值范围为 0 到 255 的整数,计算结果需要四舍五入取整,并限制在 255 以内!

那么,图像边缘或角落的像素该如何处理呢?处理边缘像素的方法有很多,但为了简化问题,请将图像视为边缘有一圈 1 像素宽的纯黑色边框。因此,访问图像边界之外的像素应视为纯黑色 (RGB 值为 0)。这相当于在计算 GxGy 时忽略了这些边界外的像素。

开始

登录 cs50.dev,单击您的终端窗口,然后单独执行 cd。您应该发现您的终端窗口的提示符类似于以下内容:

接下来执行

wget https://cdn.cs50.net/2022/fall/psets/4/filter-more.zip

以便将名为 filter-more.zip 的 ZIP 文件下载到您的 codespace 中。

然后执行

创建名为 filter-more 的文件夹。您不再需要 ZIP 文件,因此您可以执行

并在提示符下回复“y”,然后按 Enter 键以删除您下载的 ZIP 文件。

现在输入

然后按 Enter 键将自己移动到(即打开)该目录。您的提示符现在应类似于以下内容。 执行 ls 命令,你应该会看到一些文件:bmp.hfilter.chelpers.hhelpers.cMakefile。你还会看到一个名为 images 的文件夹,其中包含四个 BMP 文件。如果你遇到任何问题,请再次按照这些步骤操作,看看你能不能找到哪里出错了!

理解

现在我们来看看提供的这些代码文件,了解一下它们的内容。

bmp.h

在文件浏览器里双击打开 bmp.h 并查看。

你会看到我们之前提到的头文件 BITMAPINFOHEADERBITMAPFILEHEADER 的定义。此外,这个文件还定义了 BYTEDWORDLONGWORD 等数据类型,这些类型常见于 Windows 编程。注意,它们只是你(应该)已经熟悉的原始类型的别名。BITMAPFILEHEADERBITMAPINFOHEADER 结构体中使用了这些类型。

对你来说,最重要的可能是这个文件还定义了一个名为 RGBTRIPLE 的结构体。它简单地“封装”了三个字节:蓝色、绿色和红色(记住,这是我们在磁盘上找到RGB三元组的顺序)。

这些 struct 结构体有什么用呢?回想一下,文件在磁盘上就是一系列的字节(或者说是位)。这些字节通常按照一定的顺序排列:前几个字节代表一种信息,接下来的几个字节代表另一种信息,以此类推。文件格式之所以存在,是因为业界已经标准化了每个字节的含义。我们可以直接把文件从磁盘读取到内存里,作为一个大的字节数组。我们可以记住 array[i] 的字节代表一种信息,array[j] 的字节代表另一种信息。但是,为什么不给这些字节命名,方便我们从内存中读取呢?而 bmp.h 中的结构体正是为了方便我们做这件事。与其把文件看作一个长的字节序列,不如看作一系列的 struct 结构体。

filter.c

现在,我们打开 filter.c 文件。这个文件已经写好了,但有几个重点需要注意。

首先,注意第10行 filters 的定义。这个字符串定义了程序允许的命令行参数:begr。它们分别对应不同的图像滤镜:模糊、边缘检测、灰度化和反射。

接下来的代码打开图像文件,确认它是 BMP 格式,并将所有像素数据读取到名为 image 的二维数组中。

向下滚动到第101行的 switch 语句。注意,根据选择的 filter 参数,会调用不同的函数:b 对应 blure 对应 edgesg 对应 grayscaler 对应 reflect。同时,这些函数都接收图像的高度、宽度和像素二维数组作为参数。

这些就是你接下来要实现的函数。可以想到,目标是让这些函数修改像素的二维数组,从而实现所需的滤镜效果。

程序的剩余部分将处理后的 image 写入新的图像文件。

helpers.h

接下来,查看 helpers.h 文件。这个文件很短,只包含了之前提到的函数的原型声明。 这里请注意,每个函数都接收一个名为 image 的二维数组作为参数。image 数组包含 height 行,每一行又包含 widthRGBTRIPLE 元素。所以,如果把 image 看作是整张图片,那么 image[0] 就是第一行,image[0][0] 则是左上角的那个像素点。

helpers.c

现在,打开 helpers.c。这里是 helpers.h 中声明的函数的具体实现所在。不过要注意,这些函数的具体实现现在是空的! 剩下的就靠你自己了。

Makefile

最后,让我们看看 Makefile。此文件指定了当我们运行像 make filter 这样的终端命令时应该发生什么。你之前写的程序可能只有一个文件,但 filter 却用了好几个:filter.chelpers.c。所以,我们需要告诉 make 怎么编译这些文件。

尝试通过转到终端并运行以下命令来自己编译 filter

然后,你可以通过运行以下命令来运行该程序:

$ ./filter -g images/yard.bmp out.bmp

这条命令会读取 images/yard.bmp 这张图片,然后用 grayscale 函数处理每个像素,最后生成一个叫做 out.bmp 的新图片。不过,因为 grayscale 现在还没写任何东西,所以输出的图片应该和原来的一模一样。

规范

你需要自己在 helpers.c 里实现这些函数,这样用户才能对图片应用灰度、反射、模糊或者边缘检测这些滤镜。

  • grayscale 函数应接受一个图像,并将其转换为同一图像的黑白版本。
  • reflect 函数应接受一个图像,并将其水平翻转。
  • blur 函数应接受一个图像,并将其转换为同一图像的盒状模糊版本。
  • edges 函数应接受一个图像,并根据 Sobel 算子突出显示对象之间的边缘。

除了 helpers.c 之外,其他文件和函数的定义都不要改动。

演练

请注意,此播放列表中有 5 个视频。

用法

你的程序应该像下面这些例子一样运行。INFILE.bmp 是输入图像的名称,OUTFILE.bmp 是应用滤镜后生成的图像的名称。

$ ./filter -g INFILE.bmp OUTFILE.bmp

$ ./filter -r INFILE.bmp OUTFILE.bmp

$ ./filter -b INFILE.bmp OUTFILE.bmp

$ ./filter -e INFILE.bmp OUTFILE.bmp

提示

  • rgbtRedrgbtGreenrgbtBlue 这几个颜色分量都是整数,所以如果计算结果是小数,记得四舍五入成整数再赋值!

测试

一定要用提供的这些示例图片测试一下你写的滤镜!

运行下面的命令可以用 check50 检查代码是否正确。 不过,别忘了先自己编译运行测试一下!

check50 cs50/problems/2023/x/filter/more

运行下面的命令可以用 style50 检查代码风格。

如何提交

在终端里运行下面的命令来提交你的代码。

submit50 cs50/problems/2023/x/filter/more